feat(map-analysis): 3D terrain view + DEM tile proxy (#3826 Phase 2)#4239
Conversation
… viewMode config (#3826 Phase 2 WP-B) Adds the frontend pure/config building blocks for the MapLibre 3D map: basemap3d.ts (expandSubdomains for Leaflet {s} URLs, resolve3DBasemap with vector-tileset osm fallback, buildTerrainTileUrl honoring the deployment base path), useTerrainCapabilities (reads the enveloped /api/elevation/capabilities response with safe loading defaults), and a persisted viewMode ('2d'|'3d', default '2d') on the existing Map Analysis config, exposed through MapAnalysisContext via the existing config spread.
…ase 2 WP-A)
Backend-only half of the 3D Map Foundation epic: a server-side terrarium
DEM tile proxy and a derived, non-secret capabilities endpoint so the
frontend can gate the upcoming 3D toggle without ever seeing the secret
elevationSourceUrl setting.
- elevationProvider.ts: isValidTileCoord + fetchTerrariumTilePng (raw-PNG
fetch via safeFetch, separate 256-entry LRU from the decoded sample
cache), MAX_TERRARIUM_TILE_ZOOM=15.
- elevationService.ts: getElevationCapabilities (enabled/terrainTiles/
provider) and fetchTerrainTile, which returns a discriminated result
(ELEVATION_DISABLED 403, TERRAIN_TILES_UNAVAILABLE 409 for configured
JSON sources — no silent terrarium fallback, INVALID_TILE_COORDS 400,
TILE_FETCH_FAILED 502, or { png }).
- rateLimiters.ts: elevationTileLimiter (600/min prod, 3000/min dev),
far more generous than the existing 20/min elevationLimiter since a
single 3D view fetches dozens of tiles.
- elevationRoutes.ts: GET /capabilities (optionalAuth, ok() envelope) and
GET /tiles/:z/:x/:y (optionalAuth + tile limiter; raw image/png bytes +
immutable Cache-Control on success, fail() + no-store on error; never
echoes the source URL/key).
- Extends the three backend test files (harness-based route tests mocking
only safeFetch; provider/service unit tests) per the WP-A test plan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
Generic, Map-Analysis-agnostic MapLibre GL surface: raster basemap, raster-dem terrain + hillshade from the DEM proxy, node circle/label layers, exaggeration slider, NavigationControl + compact attribution, and symmetric mount/unmount lifecycle (map.remove() on unmount, StrictMode double-mount safe). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
- MapAnalysisToolbar: 3D View toggle button gated by useTerrainCapabilities
(disabled + "Elevation is disabled" / "3D terrain is unavailable with the
configured elevation source" tooltips; neutral-disabled while loading;
active class reflects viewMode)
- MapAnalysisCanvas: viewMode==='3d' branch renders Base3DMap fed from the
same useAnalysisNodes() data (mapped to Node3DFeature), basemap via
resolve3DBasemap, terrain tiles via buildTerrainTileUrl(appBasename),
node click -> setSelected({type:'node',...}); 2D tree untouched
- Force-2D guard: persisted '3d' with unavailable capabilities renders 2D
and corrects the stored config (no flash-to-2D while caps are loading)
- Vector-only tileset: non-blocking fallback note over the 3D map
- Tests: toolbar gating/toggle/active-state matrix; canvas 2d-vs-3d branch,
mapped node data, terrain URL, vector-fallback note, force-2D guard
(Base3DMap + useTerrainCapabilities mocked at module level — no WebGL
in jsdom)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
…3826 Phase 2) Real-machine case: WebGL context creation can fail, making `new maplibregl.Map(...)` throw uncaught and leaving the 3D pane blank with the user stranded in 3D mode. - Base3DMap: cheap webgl2/webgl canvas probe before construction PLUS try/catch around the Map constructor (probe can pass while the real context still fails). On failure: no crash, centered "3D view requires WebGL" fallback message, and a new optional onUnsupported prop invoked exactly once per instance (ref-guarded, StrictMode double-mount safe). - MapAnalysisCanvas: onUnsupported -> setViewMode('2d') so the user lands back on the working 2D map (and the persisted viewMode is corrected). - Tests: probe-failure + constructor-throw + StrictMode exactly-once + no-slider-in-fallback for Base3DMap; unsupported-signal-flips-to-2D for the canvas. jsdom returns null for WebGL contexts, so the suite stubs HTMLCanvasElement#getContext truthy by default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
Adds user-facing docs for the new pitched-terrain MapLibre GL view: a
"3D terrain view" section in map-analysis.md (toggle, exaggeration
slider, requirements, basemap fallback, what's deferred to a later
phase), a settings.md note that the elevation source also powers 3D
and that JSON point sources don't support it, the two new
GET /api/elevation/capabilities and GET /api/elevation/tiles/{z}/{x}/{y}
endpoints in REST_API.md, and a README.md feature-list entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
|
Claude finished @Yeraze's task —— View job PR Review: feat(map-analysis): 3D terrain view + DEM tile proxy (#3826 Phase 2)This is a well-structured, thoughtfully-engineered PR. The code is clean, the security model is sound, and the test coverage is thorough. Below are my findings, organized by severity. SecurityNo issues found. The sensitive areas were handled correctly:
Potential Bugs / Issues1. const settings = await databaseService.settings.getAllSettings();The tile proxy fetches all settings on every request. A single 3D map view fires dozens of tile requests concurrently; at 600 req/min that's 600 settings DB reads/min. The existing 2. const rawTileCache = new LruCache<string, Buffer>(RAW_TILE_CACHE_MAX);The SSRF-blocked test in 3. Basemap change removes the layer before the source — order matters if MapLibre defers if (map.getLayer(BASEMAP_LAYER_ID)) map.removeLayer(BASEMAP_LAYER_ID);
if (map.getSource(BASEMAP_SOURCE_ID)) map.removeSource(BASEMAP_SOURCE_ID);This is the correct order (remove layer, then source). Just confirming it's right — MapLibre will throw if you try to remove a source that still has layers referencing it. ✓ 4. it('falls back to the default tileset (osm) for an unknown tileset id', () => {
const result = resolve3DBasemap('does-not-exist', []);
expect(result.usedFallback).toBe(false); // ← test asserts usedFallback:falseIf a user has a tileset ID that's no longer valid (e.g., removed from Performance Considerations5. DEM tile proxy performs a settings read on every tile fetch 6. Code Quality / Best Practices7. console.warn('Base3DMap: WebGL map construction failed', err);The rest of the codebase uses the project 8. The const terrainTileUrl = useMemo(() => buildTerrainTileUrl(appBasename), []);
const TERRAIN_TILE_URL = buildTerrainTileUrl(appBasename);This is stylistic — the current code is functionally correct. 9. MapLibre maxzoom: 15,
Test CoverageCoverage is excellent:
One gap: There's no test for the basemap-change effect (removing and re-adding the raster source when SummaryThis is a high-quality implementation. The security-sensitive paths (URL/key isolation, SSRF guard, coordinate validation) are all handled correctly and tested. The two items most worth addressing before merge are:
Everything else is minor or stylistic. The 2D/Leaflet path is provably untouched by the feature flag logic, the force-2D guard prevents stale-persisted-3D from stranding users, and the WebGL fallback is solid. |
…lation (PR #4239 review) - elevationRoutes.ts: replace getAllSettings() (full settings-map load) with a shared readElevationSettings() helper that reads only elevationEnabled + elevationSourceUrl via settings.getSetting() — the tile route can be hit up to 600/min, so per-request full-map loads were needless DB pressure. Behavior unchanged. - elevationProvider.ts: export clearRawTileCacheForTesting() so tests can empty the module-scope raw-PNG LRU. - elevationProvider.test.ts / elevationRoutes.test.ts: clear the raw-tile cache in beforeEach so tile-path test isolation no longer depends on each test using distinct z/x/y coordinates; drop the coordinate-dodge comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1
|
Claude finished @Yeraze's task —— View job PR Review: feat(map-analysis): 3D terrain view + DEM tile proxy (#3826 Phase 2)Tasks
This is a well-engineered PR. The security model is sound, the architecture is clean, and the test coverage is thorough. Below are my findings organized by severity. Security — No IssuesThe sensitive areas are all handled correctly:
Bugs / Issues1. The The PR correctly introduced // POST /profile (line 62) — still uses getAllSettings()
const settings = await databaseService.settings.getAllSettings();While 2. Unknown tileset ID falls back to OSM but When The test at line 98-102 actually documents this as expected behavior ( 3. The validation order in Performance4. The prior review noted the cache isolation concern; the export 5. The // Inside the nodes effect:
if (!map || !loaded) return;
const source = map.getSource(NODES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
// Only need to call setData for *changes* after initial load —
// the load handler already supplied the initial data.
source?.setData(toNodesFeatureCollection(nodes));The current code is functionally correct; this is a minor optimization. Code Quality / Best Practices6.
// src/components/map/Base3DMap.tsx line 200
maxzoom: MAX_TERRARIUM_TILE_ZOOM,The literal 7.
const TERRAIN_TILE_URL = buildTerrainTileUrl(appBasename); // module-levelEither way is correct. The 8. map.addControl(
new maplibregl.AttributionControl({
compact: true,
customAttribution: [basemap.attribution, ELEVATION_ATTRIBUTION],
}),The basemap source already has an Test CoverageCoverage is excellent overall:
One gap: no test for the basemap-change effect The basemap-swap path ( SummaryMust address before merge:
Worth addressing:
Nice to have:
The 2D/Leaflet path is provably untouched, the force-2D guard prevents stale-persisted-3D from stranding users, the WebGL fallback is solid, and the security-sensitive paths (URL/key isolation, SSRF guard, coordinate validation) are all correctly handled and tested. |
Summary
Phase 2 of the 3D Map & Elevation epic (#3826): MeshMonitor gets its first real 3D map. The Map Analysis page gains a 2D/3D toggle — 3D renders a standalone MapLibre GL view with DEM terrain, hillshade, pitch/bearing navigation, a terrain-exaggeration slider, and node markers. The browser gets DEM tiles through a new server proxy that honors the admin-configured elevation source and never exposes its URL or API key. Leaflet remains untouched for 2D; the two stacks coexist behind the toggle.
Changes
Backend
GET /api/elevation/tiles/:z/:x/:y— terrarium raster-dem PNG proxy (optionalAuth, newelevationTileLimiter600/min prod,Cache-Control: public, max-age=604800, immutable; errors are envelopedfail()+no-store: 403ELEVATION_DISABLED, 409TERRAIN_TILES_UNAVAILABLE, 400INVALID_TILE_COORDS, 502TILE_FETCH_FAILED). Raw-PNG LRU (256 entries, ~30 MB ceiling) separate from the existing decoded-Float32 profile cache; reusessafeFetch/SSRF guard.GET /api/elevation/capabilities— derived{enabled, terrainTiles, provider}(server-side becauseelevationSourceUrlis secret). A configured JSON point source reportsterrainTiles:false— deliberately no silent fallback to public AWS tiles.Frontend
src/components/map/Base3DMap.tsx— standalone MapLibre GL component (not the Leaflet adapter): terrariumraster-demterrain + hillshade, initial pitch 60°, NavigationControl with pitch, exaggeration slider (0–2×, default 1.3×), GeoJSON node circle+label layers, click→select,map.remove()teardown (StrictMode-safe). Graceful WebGL-unavailable fallback: probe + try/catch → message +onUnsupported→ Map Analysis auto-reverts to 2D (found via browser validation — previously a no-WebGL browser with persisted 3D crashed to a blank page).src/config/basemap3d.ts— Leaflet{s}subdomain expansion for MapLibretiles[], vector-tileset → OSM raster fallback (with on-map note), base-path-aware proxy URL.useTerrainCapabilitieshook;viewMode: '2d'|'3d'persisted in the existingmapAnalysis.config.v1; toolbar toggle disabled with specific tooltips per unavailability reason; force-2D guard so a stale persisted'3d'never strands the user.viewMode==='2d'.Docs: Map Analysis 3D section, settings elevation note, REST API entries for both endpoints, README feature bullet, epic plan + implementation spec in dev-notes.
Issues Resolved
Relates to #3826 (Phase 2 of 3). Phase 1 was #4235.
Documentation Updates
docs/features/map-analysis.md(3D terrain view section + toolbar table row)docs/features/settings.md(elevation source ↔ 3D availability)docs/api/REST_API.md(capabilities + tiles endpoints)README.md(Key Features bullet)3D_MAP_FOUNDATION_SPEC.md,MAP_3D_ELEVATION_EPIC.mdstatusTesting
success: truevia JSON reporter (route tests usecreateRouteTestAppharness; maplibre-gl fully mocked in jsdom)npm run lint:ciclean (no baseline growth)🤖 Generated with Claude Code
https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1